14 模块化
> Last Format Time:8/11/2026 21:45:41
本笔记由两个 JavaScript 教程目录中的同主题内容合并而成;全部旧知识保留在“旧笔记知识全集(按来源保留)”中,并由导航逐项索引。
当前前端开发关键要点
> [!important] 学习优先级:P0 > - 浏览器与现代前端构建默认优先 ESM,理解静态分析、tree shaking 和动态 import()。 > - 明确 ESM 的实时绑定与 CommonJS 导出对象语义,处理好默认导出、具名导出和互操作。 > - 避免隐蔽循环依赖;用清晰分层、依赖方向与按需拆包控制模块边界。
知识点导航
[现代JS教程与阮一峰ES6 · 7模块/CJS和ESM](./14 模块化.md)
- 核心区别对比表
- 详细解释
- 来源与历史
- 语法
- 加载方式:静态 vs. 动态
- 加载时机:同步 vs. 异步
- 值引用 vs. 值拷贝
- 运行环境
- 总结与如何选择
[现代JS教程与阮一峰ES6 · 7模块/Module(模块)语法](./14 模块化.md)
- 导入导出类型不匹配错误
- 路径错误
- 重复导入
- 导出变量未定义
- 循环依赖
- 导入导出的语法错误
现代前端补充与纠错
> [!info] 修改标记 > - 【修正】:旧教程中错误、过时或容易误导的内容。 > - 【补充】:旧教程未覆盖、但当前前端开发需要掌握的内容。 > - 【修正代码】:替换或校正了旧代码示例。
> [!warning] 下方 CommonJS/ESM 对比含历史化简。现代 Node.js 已能在满足条件时由 require() 加载同步 ESM,不能再笼统写成“完全不能加载”。
ESM 核心语义
- 【补充】 ESM 的导入是只读实时绑定,静态结构支持依赖分析、Tree Shaking 和循环依赖检查;模块只求值一次。
- 【补充】
import()返回 Promise,适合按路由/功能拆包;import.meta提供模块环境信息。 - 【补充】 浏览器相对导入通常需要完整路径;Node/打包器解析还受
package.json的type、exports、imports和条件导出影响。 - 【补充】 Tree Shaking 依赖静态 ESM 与副作用分析;
sideEffects配置错误可能删掉必要代码或保留无用代码。
CommonJS 边界
- 【修正】
require()返回缓存的module.exports值;若它是对象,消费者拿到的是该对象引用,不能概括为“值拷贝”。解构赋值才会把当时属性值取到局部绑定。 - 【修正】 当前 Node.js 可用
require()加载满足同步条件、且不含顶层await的 ESM;具体限制随 Node 版本变化,工程文档应标明运行时版本。 - 【补充】 CJS 与 ESM 互操作的默认导入、命名导入和循环依赖容易产生差异,库发布应通过真实 Node/打包器矩阵验证。
旧笔记知识全集(按来源保留)
现代JS教程与阮一峰ES6 · 7模块/CJS和ESM
好的,ESM(ECMAScript Modules)和 CJS(CommonJS)是 JavaScript 中两种最主要的模块系统。它们在设计哲学、语法和运行方式上有根本性的区别。
以下是它们之间的核心区别,我用一个对比表格来清晰展示,然后进行详细解释。
核心区别对比表
| 特性 | ESM (ECMAScript Modules) | CJS (CommonJS) |
|---|---|---|
| 来源与标准 | 语言标准 (ES6 / ES2015) | 社区规范 (主要用于 Node.js 环境) |
| 语法 - 导出 | export default myComponent; <br> export const foo = 'bar'; | module.exports = myComponent; <br> exports.foo = 'bar'; |
| 语法 - 导入 | import myComponent from './module.js'; <br> import { foo } from './module.js'; | const myComponent = require('./module'); |
| 加载方式 | 静态 (编译时) | 动态 (运行时) |
| 加载时机 | 静态链接;浏览器可异步获取,top-level await 会使求值异步 | require() 通常同步加载与求值 |
| 运行环境 | 现代浏览器、现代 Node.js | Node.js (传统)、打包工具 (如 Webpack) |
| Tree Shaking | 静态结构便于打包器分析 | 动态 require() 较难静态分析 |
| 导入语义 | 导入的是只读实时绑定 | 返回缓存的 module.exports 值;对象通常共享引用 |
| 文件后缀 | 通常为 .js, .mjs | 通常为 .js, .cjs |
顶层的 this | undefined | 指向 module.exports |
详细解释
来源与历史
- CJS (CommonJS):诞生于2009年,旨在为浏览器之外的 JavaScript(主要是 Node.js)提供模块系统。它不是一个语言标准,而是一个社区规范。Node.js 的早期版本完全基于 CJS。
- ESM (ECMAScript Modules):是 JavaScript 语言在 ES2015 (ES6) 标准中正式引入的模块系统。它的目标是为 JavaScript 提供一个统一的、官方的模块方案,无论是在浏览器还是其他环境中。
语法
这是最直观的区别。
- CJS 使用
require()和module.exports/exports:
// 导出
module.exports = {
functionA,
variableB
};
// 或者
exports.functionA = functionA;
// 导入
const myModule = require('./my-module');
const { functionA } = require('./my-module');
- ESM 使用
import和export:
// 导出
export default functionA;
export const variableB = 'Hello';
// 导入
import myModule from './my-module.js';
import { variableB } from './my-module.js';
// 或者混合导入
import myDefault, { namedExport } from './my-module.js';
加载方式:静态 vs. 动态
这是最关键的区别,它导致了其他所有差异。
ESM 是静态的:
import命令必须在模块的顶层作用域使用,不能写在条件判断或函数内。- 这意味着模块的依赖关系在代码编译阶段(编译时) 就已经确定下来了。
- 优势:这使得打包工具(如 Webpack、Rollup)可以轻松地进行静态分析,从而实现
Tree Shaking(摇树优化),剔除那些从未被导出和导入的代码,极大减小最终打包体积。
CJS 是动态的:
require()是一个函数,可以在代码的任何地方被调用,包括条件语句和函数内部。- 这意味着模块的依赖关系要到代码执行阶段(运行时) 才能确定。
- 劣势:无法在打包前静态分析出所有依赖,因此难以实现完美的 Tree Shaking。
加载时机:同步 vs. 异步
- CJS 是同步加载的:
require()语句会同步地阻塞代码执行,直到模块被加载并执行完毕,才会继续执行后面的代码。这在服务器端(Node.js)读写本地文件是可行的。 8/11/26 【修正】原内容:ESM 一概属于异步加载;修改点:静态链接、宿主获取和模块求值是不同阶段,浏览器获取模块通常异步,top-level await会让依赖图求值异步。 - ESM 先静态链接,再由宿主加载和求值:浏览器会获取模块依赖图且不按经典脚本方式阻塞 HTML 解析;是否发生异步求值还取决于
top-level await。
值引用 vs. 值拷贝
这个区别在处理循环依赖时尤为明显。
- ESM 是值的动态只读引用:
- 导入绑定会实时读取导出模块中的绑定,不应解释成两个模块指向同一个内存地址。
- 当导出的模块修改了一个值,导入模块中看到的值也会随之改变。
- 导入的值是只读的,你不能直接修改它(除非导出的是一个对象,你可以修改其属性)。
8/11/26 【修正】原内容:CJS 导入统一属于值拷贝;修改点:require() 返回缓存的 module.exports 值,对象导出通常是共享引用,解构才取得当时的属性值。
- CJS 的导入语义取决于导出值和消费方式:
require()返回缓存的module.exports值;若导出对象,消费者通常共享同一个对象引用。const { value } = require(...)会在解构时取得属性值;后续重新赋值导出属性不会自动更新这个局部变量。
示例
// counter.js (ESM)
export let count = 0;
export const increment = () => { count++; };
// main.js (ESM)
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 (值被改变了,因为是对同一内存地址的引用)
// counter.js (CJS)
let count = 0;
exports.increment = () => { count++; };
exports.getCount = () => count; // 需要一个getter来获取最新值
// main.js (CJS)
const { increment, getCount } = require('./counter');
let count = getCount();
console.log(count); // 0
increment();
console.log(getCount()); // 1
console.log(count); // 0 (原始的count变量仍是拷贝时的值,未变)
运行环境
- CJS:原生支持于 Node.js。在浏览器中直接使用需要打包工具处理。
- ESM:
- 现代浏览器 已原生支持 (
<script type="module">)。 - 现代 Node.js (v12+) 也已原生支持,通常通过
.mjs文件扩展名或package.json中设置"type": "module"来启用。
- 现代浏览器 已原生支持 (
总结与如何选择
- 新项目:毫无疑问应该使用 ESM。它是语言标准,支持 Tree Shaking,并且是未来的方向。
- Node.js 开发:
- 新项目建议在
package.json中设置"type": "module"来使用 ESM。 - 旧的库和项目可能仍然使用 CJS。
- 新项目建议在
- 旧项目/库:如果是一个旧的 CJS 项目,除非有充分理由,否则迁移到 ESM 的成本可能较高。但如果要发布新的库,最好提供 ESM 格式的版本,以便用户能进行 Tree Shaking。
- 在 Node.js 中互操作:
- 在 ESM 模块中可以使用
import()动态导入 CJS 模块。 8/11/26 【修正】原内容:CJS 无法用require()加载 ESM;修改点:当前 Node.js 可加载满足同步条件且不含顶层await的 ESM,限制应按运行时版本确认。
- 在 ESM 模块中可以使用
- CJS/ESM 互操作应按 Node.js 目标版本、模块同步性和包导出配置验证。
简单来说,ESM 是现代化开发的标配,而 CJS 是历史遗留和特定环境(如传统 Node.js)下的产物。
现代JS教程与阮一峰ES6 · 7模块/Module(模块)语法
在ES6模块中,导入和导出的使用如果不规范,很容易产生错误。以下是一些常见错误及其解决方法:
导入导出类型不匹配错误
错误表现:如前面提到的,使用默认导出的模块却用具名导入方式,或者使用具名导出的模块却用默认导入方式,导致找不到对应导出项,报错 No matching export in "xxx" for import "xxx" 。 错误示例:
- 导出文件(
module.js):
const data = { message: 'Hello' };
export default data;
- 导入文件(
main.js):
import { data } from './module.js'; // 错误,这里使用了具名导入,但module.js是默认导出
解决方法:
- 如果是默认导出,导入时直接使用默认导入形式:
import data from './module.js'; // 正确,使用默认导入
- 如果想使用具名导入,将导出文件修改为具名导出:
export const data = { message: 'Hello' };
然后在导入文件中使用具名导入:
import { data } from './module.js'; // 正确,使用具名导入
路径错误
错误表现:导入模块时指定了错误的文件路径,导致无法找到模块,报错类似于 Cannot find module 'xxx' 。 错误示例:
import { someFunction } from '../wrongPath/module.js'; // 假设模块实际在../correctPath/module.js
解决方法:仔细检查并确保导入路径与模块实际存放路径一致,修改为正确路径:
import { someFunction } from '../correctPath/module.js';
重复导入
错误表现:多次导入同一个模块,虽然在ES6中重复导入不会报错,但可能会导致代码可读性变差,且存在不必要的资源消耗。 错误示例:
import { someFunction } from './module.js';
import { anotherFunction } from './module.js';
// 又一次导入整个模块
import * as module from './module.js';
解决方法:尽量将多次导入合并为一次导入,按需获取需要的导出项:
import { someFunction, anotherFunction } from './module.js';
或者使用通配符导入后按需访问:
import * as module from './module.js';
const result1 = module.someFunction();
const result2 = module.anotherFunction();
导出变量未定义
错误表现:在导出时,导出了一个未定义的变量,导致运行时报错。 错误示例:
export { nonExistentVariable }; // nonExistentVariable未定义
解决方法:确保导出的变量已经被正确声明和赋值:
const definedVariable = 'This is defined';
export { definedVariable };
循环依赖
错误表现:模块A导入模块B,而模块B又导入模块A,形成循环引用,可能导致某些变量未初始化就被使用,从而报错。 错误示例:
moduleA.js:
import { bFunction } from './moduleB.js';
export const aFunction = () => {
console.log('Inside aFunction');
bFunction();
};
moduleB.js:
import { aFunction } from './moduleA.js';
export const bFunction = () => {
console.log('Inside bFunction');
aFunction();
};
解决方法:
- 重构代码,避免循环依赖,比如将两个模块中互相依赖的部分提取到一个独立的新模块中。
- 如果无法避免循环依赖,可以尽量确保在循环引用中,被使用的变量在引用前已经被正确初始化。例如,在Node.js环境中,ES6模块的加载机制会先扫描所有导入导出语句,然后按照一定顺序执行模块代码,合理安排代码执行顺序有时能避免问题。
导入导出的语法错误
错误表现:在使用导入导出语法时,写错关键字或者语法格式,比如忘记写 export 或者 import 等,导致语法错误。 错误示例:
const myFunction = () => {
console.log('Function');
};
function { // 这里少了export关键字,不是一个正确的导出语句
myFunction();
}
解决方法:仔细检查导入导出的语法,确保关键字拼写正确,语句格式符合ES6规范。比如上面的代码修改为:
const myFunction = () => {
console.log('Function');
};
export function myExportedFunction() {
myFunction();
}
通过export命令显式指定输出的代码,再通过import命令输入。
// ES6模块
import { stat, exists, readFile } from 'fs';
ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";。 一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。下面是一个 JS 文件,里面使用export命令输出变量。
// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;
或者export {firstName,lastName,year};
通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。
function v1() { ... }
function v2() { ... }
export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};
上面代码使用as关键字,重命名了函数v1和v2的对外接口。重命名后,v2可以用不同的名字输出两次。
需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
// 报错
export 1;
// 报错
var m = 1;
export m;
上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出 1,第二种写法通过变量m,还是直接输出 1。1只是一个值,不是接口。正确的写法是下面这样。
// 写法一
export var m = 1;
// 写法二
var m = 1;
export {m};
// 写法三
var n = 1;
export {n as m};
上面三种写法都是正确的,规定了对外的接口m。其他脚本可以通过这个接口,取到值1。它们的实质是,在接口名与模块内部变量之间,建立了一一对应的关系。
同样的,function和class的输出,也必须遵守这样的写法。
// 报错
function f() {}
export f;
// 正确
export function f() {};
// 正确
function f() {}
export {f};
目前,export 命令能够对外输出的就是三种接口:函数(Functions),类(Classes),var、let、const 声明的变量(Variables)。
另外,export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。 export命令可以出现在模块的任何位置,只要处于模块顶层就可以。如果处于块级作用域内,就会报错,下一节的import命令也是如此。这是因为处于条件代码块之中,就没法做静态优化了,违背了 ES6 模块的设计初衷。
如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。
import { lastName as surname } from './profile.js';
import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。
import {a} from './xxx.js'
a = {}; // Syntax Error : 'a' is read-only;
import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径。如果不带有路径,只是一个模块名,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。
import { myMethod } from 'util';
上面代码中,util是模块文件名,由于不带有路径,必须通过配置,告诉引擎怎么取到这个模块。
注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。
为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。
// export-default.js
export default function () {
console.log('foo');
}
上面代码是一个模块文件export-default.js,它的默认输出是一个函数。
其他模块加载该模块时,import命令可以为该匿名函数指定任意名字。
// import-default.js
import customName from './export-default';
customName(); // 'foo'
上面代码的import命令,可以用任意名称指向export-default.js输出的方法,这时就不需要知道原模块输出的函数名。需要注意的是,这时import命令后面,不使用大括号。
如果在一个模块之中,先输入后输出同一个模块,import语句可以与export语句写在一起。
export { foo, bar } from 'my_module';
// 可以简单理解为
import { foo, bar } from 'my_module';
export { foo, bar };